NUL-217: Wire session-expired listener into AuthGuard (NUL-50.4) - #2
Merged
Conversation
Audit found the repo-derived auth flow was complete except for the
session-expired → /login redirect listener:
- `http-client.ts` was already dispatching `ipam:session-expired`
on every non-login /api/** 401 (NUL-50.4), but nothing in the
tree subscribed to the event.
- As a result, a stale-token mutation could leave the user on a
protected page with stale data indefinitely.
Changes:
- Extract the listener wiring into pure functions in
`route-guard-logic.ts` (`decideSessionExpiredRedirect` +
`makeSessionExpiredHandler`) so they are testable under node:test
without rendering React.
- Wire those into `<AuthGuard>` via a small useEffect that:
1. Subscribes to `ipam:session-expired`.
2. Skips if the user is already on /login.
3. Clears the ['me'] cache (remove + invalidate) so the next
render sees an anonymous viewer.
4. Navigates to /login?from=<current> via replace.
- Add 10 new tests covering the decision logic + handler wiring.
- Extend the route-guard test shim with stubs for
@tanstack/react-query (useQueryClient) and @/lib/api/http-client
(SESSION_EXPIRED_EVENT + spy dispatchSessionExpired) so the
existing component tests still pass with the new dependency.
Test runner fix (NUL-217 audit):
`scripts/_run-tests.mjs` had two latent bugs that meant
`npm test` ran nothing:
1. `node --test` does not understand `**`, so the default glob
`src/**/*.test.ts` resolved to a literal file that does not
exist and the runner bailed out with `Could not find ...`.
2. The wrapper also forgot to pass the `--test` flag, so when
the glob was somehow resolved the args were loaded as a
script rather than as tests.
Both are fixed: the wrapper now expands `**` itself via a small
recursive walk and emits the `--test` flag. Verified end-to-end:
npm test # 139 passed, 0 failed, ~19s
npm run typecheck # clean
npm run build # clean
Out of scope per the NUL-217 plan: backend/cookie/schema/RBAC
changes. Auth-touching (Block) — Sentinel review and explicit
founder override are still required before Relay merges this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
NUL-217 — Audit and complete the repo-derived frontend authentication flow
Audit result
The plan-named files (
src/features/auth/*,src/store/tenant-store.ts,src/lib/api/http-client.ts,src/components/layout/topbar.tsx,src/routes/__root.tsx) are all present and well-structured onmain. The gap was a single missing wire: nothing was listening for theipam:session-expiredevent thatapiFetchwas already dispatching on non-login/api/**401 responses (NUL-50.4).A stale-token mutation would leave the user on a protected page indefinitely until the next manual navigation re-triggered the route guard's
/mequery. That breaks the "Non-login/api/**401 clears session and preserves destination" acceptance criterion.Changes
Auth — NUL-50.4 listener (the actual gap)
src/features/auth/route-guard-logic.ts— added two pure functions:decideSessionExpiredRedirect(currentPath, currentSearch)— sanitises the preservedfromand short-circuits when the user is already on/login.makeSessionExpiredHandler({ currentPath, currentSearch, queryClient, navigate, onNoOp })— clears the['me']cache (remove + invalidate) and navigates to/login?from=<from>, returning a callable handler.src/features/auth/route-guard.tsx—useEffectsubscribes toSESSION_EXPIRED_EVENTand wires the handler in. The cache is wiped before navigation sodecideRedirectsees an anonymous viewer on the next render.src/features/auth/route-guard.test.ts— 10 new tests pinning the decision contract and the cache-clear → navigate ordering.scripts/_route-guard-shim.mjs+ two new test mocks (scripts/_test-mocks/http-client.mjs,scripts/_test-mocks/react-query.mjs) so the existing component tests still pass with the newuseQueryClient/http-clientimports.Test runner fix (NUL-217 audit)
scripts/_run-tests.mjshad two latent bugs that meantnpm testran nothing:node --testdoes not understand**, so the default globsrc/**/*.test.tswas a literal non-existent file and the runner bailed out withCould not find ....--testflag, so even when the glob was resolved the args loaded as a script.Both fixed: the wrapper now expands
**itself via a small recursive walk and emits--testalongside--import tsx. Verified end-to-end:Acceptance criteria — verified
/loginpreserving a safe path/query without flash['me']and returns to destination/api/auth/me; no hardcoded defaults/login/api/**401 clears session and preserves destination; 403 does not log outnpm run typecheck+npm test+npm run buildpass with recorded outputnpm testpreviously ran nothing)Out of scope (per NUL-217 plan)
Risk + handoff
Auth-touching / Block. Sentinel review and explicit founder override are required before Relay merges this. Next: local smoke, then Sentinel review.
Evidence
mainand pass their existing tests (38 → 48 auth tests after this PR; 1 → 1 tenant-store test).feat/nul-217-auth-flow.1b7784f.